logo头像

学如逆水行舟

iOS单元测试的那些事儿

iOS单元测试的那些事儿

作为客户端开发,很多时候我们过多的关注于功能的测试,而忽略标准的单元测试。其实,单元测试是保障项目稳定性的最有效且成本最低的测试方式。越偏向底层服务的代码,越需要使用单元测试来对可靠性进行保障。一旦单元测试覆盖完成,则之后再进行代码优化和迭代的时候则会有引入新问题的几率会大为减小。

Xcode提供了完整的单元测试功能,系统预置的单元测试类和断言也非常方便开发者编写测试代码。除了函数功能测试,性能测试外,也支持进行UI上的单元测试。本篇文章,我们就将介绍iOS中关于单元测试的那些事。

一 先看一个简单的单元测试例子

首先可以新建一个iOS测试工程,在工程中任意添加一个示例类文件,例如命名为ViewModel类,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//ViewModel.h文件
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface ViewModel : NSObject

- (NSInteger)getSegementCount:(NSString *)string;

@end

NS_ASSUME_NONNULL_END


//ViewModel.m文件
#import "ViewModel.h"

@implementation ViewModel

- (NSInteger)getSegementCount:(NSString *)string {
return [string componentsSeparatedByString:@":"].count;
}

@end

这个类本身非常简单,只提供了一个获取字符串分段数的方法。此方法只要有明确的输入就会有明确的输出,非常适合用来做单元测试。之后,使用Xcode新建一个Unit Testing Bundle的Target模块,如下图:

之后默认会生成一个测试文件,其只有.m文件,没有.h文件,我们的主要测试代码也都将编写到这个.m文件中。

生成的测试文件中默认实现了setUp,tearDown,testExample和testPerformanceExample这些方法,等下我们会对这些方法进行介绍,修改此测试文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#import <XCTest/XCTest.h>
#import "ViewModel.h"

@interface UnitTestDemoTests : XCTestCase
@property (nonatomic, strong) ViewModel *viewModel;
@end

@implementation UnitTestDemoTests

- (void)setUp {
self.viewModel = [[ViewModel alloc] init];
}

- (void)tearDown {
self.viewModel = nil;
}

- (void)testExample {
NSInteger count = [self.viewModel getSegementCount:@"127:0:0:1"];
XCTAssertEqual(count, 4);
}

- (void)testPerformanceExample {
[self measureBlock:^{
NSLog(@"---");
}];
}

@end

其中setUp方法是当前测试类的初始化方法,我们可以将一些资源准备工作在这个方法中完成,tearDown方式在测试结束后会调用,用来进行资源的清理。测试函数都需要以text开头,testExample是默认生成的一个测试用例函数,我们在其中检查getSegmentCount方法的工作是否正常,XCTAssertEqual是XCTest框架提供的众多测试断言中的一种,用来进行相等断言,如果getSegmentCount方法执行的结果与我们预期不一致,则会命中此断言,从而使当前测试用例失败。testPerformanceExample是性能测试的一个案例,其内的measureBlock里的代码会被默认执行10次,最终输出每次执行的时间消耗报告。

下面,我们可以执行下此测试类,在Xcode的测试导航中点击此测试类右边的执行按钮即可:

每个测试方法的结果会在右侧展示,绿的的对号表示此测试用例通过。也可以直接在测试类文件中执行单个的测试用例,如下:

对于性能测试用例,其测试完成后会自动生成一个性能报告,对每个性能测试函数,我们都可以为其设置一个基准值,其会分析性能优于或劣于基准值多少。如下图:

二 关于XCTestCase类

XCTestCase可以理解为一个测试用例类,其中可以定义多个测试用例函数。通常最佳的实践是一个功能类对应一个XCTestCase测试类,在此测试类中对相应的功能类进行覆盖测试。

要定义一个测试用例类非常简单,遵循如下的步骤即可:

1. 创建一个XCTestCase的子类。

2. 自定义以test开头的实例方法,作为独立的测试用例。

3. 可以定义一些需要保持状态的变量或属性作为测试物料。

4. 某些需要初始化的状态在setup方法中设置。

5. 测试完成后的清理工作在tearDown方法中设置。

对于自定义的测试实例方法,有3个非常重要的原则,符合这3个原则的方法才会被系统识别为测试用例,即:没有入参,没有返回值,以test开头。

XCTestCase也支持进行更多定制化配置,例如超时时间,测试异常的记录等。XCTestCase类中提供的初始化方法如下:

1
2
3
4
5
6
7
8
9
10
11
// 通过invocation构造测试类
+ (instancetype)testCaseWithInvocation:(nullable NSInvocation *)invocation;

// 实例的初始化方法
- (instancetype)initWithInvocation:(nullable NSInvocation *)invocation;

// 通过selector构造测试类
+ (nullable instancetype)testCaseWithSelector:(SEL)selector;

// 实例方法
- (instancetype)initWithSelector:(SEL)selector;

通常,如果我们需要定制XCTestCase的属性,可以在子类中重写initWithInvocation方法,如下:

1
2
3
4
5
6
7
8
9
10
- (instancetype)initWithInvocation:(nullable NSInvocation *)invocation {
self = [super initWithInvocation:invocation];
if (self) {
self.continueAfterFailure = NO;
[self addTeardownBlock:^{
NSLog(@"TTT");
}];
}
return self;
}

XCTestCase类中封装的属性和方法解析如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 测试用例执行时 调用的invocation
@property (strong, nullable) NSInvocation *invocation;

// 执行测试,此方法应该由系统框架调用,不能主动调用
- (void)invokeTest;

// 设置当某个测试用例方法没通过时,是否继续执行其后的逻辑
@property BOOL continueAfterFailure;

// 测试用例不通过后,会回调此方法,子类可以重写来自定义异常报告
- (void)recordIssue:(XCTIssue *)issue;

// 每个测试用例所对应的NSInvocation
@property (class, readonly, copy) NSArray<NSInvocation *> *testInvocations;

// 添加一个自定义的tearDown回调
- (void)addTeardownBlock:(void (^)(void))block;

// 添加一个异步执行的tearDown回调
- (void)addAsyncTeardownBlock:(void (^)(void (^completion)(NSError * _Nullable error)))block;

// 用例执行超时时间 默认10min
@property NSTimeInterval executionTimeAllowance;

// 性能测试方法,将要测试性能的逻辑代码放入block即可
- (void)measureBlock:(XCT_NOESCAPE void (^)(void))block;

// 性能测试方法,在block中需要手动启动和结束性能测试 配套下面两个方法使用
- (void)measureMetrics:(NSArray<XCTPerformanceMetric> *)metrics automaticallyStartMeasuring:(BOOL)automaticallyStartMeasuring forBlock:(XCT_NOESCAPE void (^)(void))block;
// 开启性能测试
- (void)startMeasuring;
// 结束性能测试
- (void)stopMeasuring;

// 默认的测试组,每个测试方法都是一个独立的测试用例,当前测试类可以定义一组
@property (class, readonly) XCTestSuite *defaultTestSuite;
// 每个测试方法执行前都会执行的setup步骤
+ (void)setUp;
// 每个测试方法执行前都会执行的teardown步骤
+ (void)tearDown;

XCTestCase也实现了XCTActivity协议,允许直接向测试用例对象中添加附件,方法如下:

1
- (void)addAttachment:(XCTAttachment *)attachment;

被添加的附件会被Xcode持有,并根据策略保存到测试报告中去。附件有时候对测试来说非常重要,例如要保存失败测试的案例数据,以便开发进行追溯,这是就可以将数据作为附件保存。例如:

1
2
3
4
5
6
7
8
- (void)testExample {
NSInteger count = [self.viewModel getSegementCount:@"127:0:0:1"];
XCTAssertEqual(count, 4);
XCTAttachment *attachment = [XCTAttachment attachmentWithString:@"我是附件"];
attachment.lifetime = XCTAttachmentLifetimeKeepAlways;
[self addAttachment:attachment];
NSLog(@"xxx");
}

执行测试后,在测试报告中可以查看用例的附件文件,如下图:

关于附件的相关内容,后面会在介绍。最后,关于XCTestCase的性能测试,可以通过设置Option参数来控制循环次数,例如:

1
2
3
4
5
6
7
- (void)testPerformanceExample {
XCTMeasureOptions *op = [[XCTMeasureOptions alloc] init];
op.iterationCount = 20;
[self measureWithOptions:op block:^{
NSLog(@"---");
}];
}

此时,性能测试代码将被循环执行20次。

三 测试附件

前面说过,附件可以用来保存测试执行时的案例数据,帮助开发者后续回溯。XCTAttachment类封装的常用属性和方法列举如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 初始化相关
- (instancetype)initWithUniformTypeIdentifier:(nullable NSString *)identifier
name:(nullable NSString *)name
payload:(nullable NSData *)payload
userInfo:(nullable NSDictionary *)userInfo;
+ (instancetype)attachmentWithUniformTypeIdentifier:(nullable NSString *)identifier
name:(nullable NSString *)name
payload:(nullable NSData *)payload
userInfo:(nullable NSDictionary *)userInfo;

// 文件标识符
@property (readonly, copy) NSString *uniformTypeIdentifier;
// 附件名
@property (copy, nullable) NSString *name;
// 用户附加数据
@property (copy, nullable) NSDictionary *userInfo;
// 附件的生存时间
/*
typedef NS_ENUM(NSInteger, XCTAttachmentLifetime) {
// 一直存在
XCTAttachmentLifetimeKeepAlways = 0,
// 测试用例通过后将附件删除
XCTAttachmentLifetimeDeleteOnSuccess = 1
};
*/
@property XCTAttachmentLifetime lifetime;

// 通过指定类型的数据直接创建附件
+ (instancetype)attachmentWithData:(NSData *)payload;
+ (instancetype)attachmentWithData:(NSData *)payload uniformTypeIdentifier:(NSString *)identifier;
+ (instancetype)attachmentWithString:(NSString *)string;
+ (instancetype)attachmentWithArchivableObject:(id<NSSecureCoding>)object;
+ (instancetype)attachmentWithArchivableObject:(id<NSSecureCoding>)object uniformTypeIdentifier:(NSString *)identifier;
+ (instancetype)attachmentWithPlistObject:(id)object;
+ (instancetype)attachmentWithContentsOfFileAtURL:(NSURL *)url;
+ (instancetype)attachmentWithContentsOfFileAtURL:(NSURL *)url uniformTypeIdentifier:(NSString *)identifier;
+ (instancetype)attachmentWithCompressedContentsOfDirectoryAtURL:(NSURL *)url;
+ (instancetype)attachmentWithImage:(UIImage *)image;
+ (instancetype)attachmentWithImage:(UIImage *)image quality:(XCTImageQuality)quality;

四 测试断言

测试用例的通过与否是由断言决定的,XCTest框架中提供的断言宏列举如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 无条件的异常断言
XCTFail(...)
// 空断言,当表达式不是空时测试失败
XCTAssertNil(expression, ...)
// 非空断言,当表达式为空时测试失败
XCTAssertNotNil(expression, ...)
// 布尔断言,当表达式为false时测试失败
XCTAssert(expression, ...)
// 布尔断言,当表达式为false时测试失败
XCTAssertTrue(expression, ...)
// 布尔断言,当表达式为true时测试失败
XCTAssertFalse(expression, ...)
// 相等断言,当两个表达式结果不相等时测试失败 使用equal方法
XCTAssertEqualObjects(expression1, expression2, ...)
// 不相等断言,当两个表达式结果相等时测试失败 使用equal方法
XCTAssertNotEqualObjects(expression1, expression2, ...)
// 相等断言,当两个表达式结果不相等时测试失败 使用 ==
XCTAssertEqual(expression1, expression2, ...)
// 相等断言,当两个表达式结果相等时测试失败 使用 ==
XCTAssertNotEqual(expression1, expression2, ...)
// 实例断言,当两个表达式结果为不相同的类实例时,测试失败
XCTAssertIdentical(expression1, expression2, ...)
// 实例断言,当两个表达式结果为相同的类实例时,测试失败
XCTAssertNotIdentical(expression1, expression2, ...)
// 差异断言,两表达式结果的差异大于设置阈值时测试失败
XCTAssertEqualWithAccuracy(expression1, expression2, accuracy, ...)
// 差异断言,两表达式结果的差异不大于设置阈值时测试失败
XCTAssertNotEqualWithAccuracy(expression1, expression2, accuracy, ...)
// 大于断言,表达式1的值小于等于表达式2时测试失败
XCTAssertGreaterThan(expression1, expression2, ...)
// 大于等于断言,表达式1的值小于表达式2时测试失败
XCTAssertGreaterThanOrEqual(expression1, expression2, ...)
// 小于断言,表达式1的值大于等于表达式2时测试失败
XCTAssertLessThan(expression1, expression2, ...)
// 小于等于断言,表达式1的值大于表达式2时测试失败
XCTAssertLessThanOrEqual(expression1, expression2, ...)
// 异常断言,当表达式没有抛出异常时测试失败
XCTAssertThrows(expression, ...)
// 特殊异常断言,当表达式抛出的异常不是指定的类时测试失败
XCTAssertThrowsSpecific(expression, exception_class, ...)
// 特殊异常断言,当表达式抛出的异常不是指定的类和名字时测试失败
XCTAssertThrowsSpecificNamed(expression, exception_class, exception_name, ...)
// 无异常断言,当表达式有异常抛出时测试失败
XCTAssertNoThrow(expression, ...)
// 无特殊异常断言,当表达式有指定的异常抛出时测试失败
XCTAssertNoThrowSpecific(expression, exception_class, ...)
// 无特殊异常断言,当表达式有指定的异常抛出时测试失败
XCTAssertNoThrowSpecificNamed(expression, exception_class, exception_name, ...)

五 代码覆盖率

与单元测试相关的,还有一个重要的概念:代码覆盖率。代码覆盖率是指在整个测试执行过程中,覆盖到的功能函数与所有功能函数的比例。覆盖率越高说明测试涉及的功能越全。

测试完成后,可以直接在Xcode中查看代码覆盖率,如下图所示:

单元测试保持较高的覆盖率是非常重要的,其从另一个方面也是测试质量的保障。

六 异步函数的测试

前面我们演示的测试用例所执行的逻辑都是同步的,但在实际的项目中,异步的操作很多,XCTest框架中也提供了异步逻辑的测试方式。例如对如下业务方法进行测试:

1
2
3
4
5
6
7
- (void)requestData:(void (^)(BOOL))complete {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
if (complete) {
complete(YES);
}
});
}

测试用例如下:

1
2
3
4
5
6
7
8
9
10
- (void)testAsync {
XCTestExpectation *except = [self expectationWithDescription:@"异步请求测试用例"];
[self.viewModel requestData:^(BOOL success) {
XCTAssertTrue(success);
[except fulfill];
}];
[self waitForExpectationsWithTimeout:10 handler:^(NSError * _Nullable error) {

}];
}

XCTestExpectation可以理解为一个期望对象,当使用此对象调用fulfill方法后,表示异步逻辑完成,XCTestCase类本身与异步测试相关的方法列举如下:

1
2
3
4
5
6
// 创建一个XCTestExpectation对象
- (XCTestExpectation *)expectationWithDescription:(NSString *)description;
// 等待异步操作结果,可以设置超时时间
- (void)waitForExpectationsWithTimeout:(NSTimeInterval)timeout handler:(nullable XCWaitCompletionHandler)handler;
- (void)waitForExpectations:(NSArray<XCTestExpectation *> *)expectations timeout:(NSTimeInterval)seconds;
- (void)waitForExpectations:(NSArray<XCTestExpectation *> *)expectations timeout:(NSTimeInterval)seconds enforceOrder:(BOOL)enforceOrderOfFulfillment;

XCTestExpectation类中封装的常用属性和方法列举如下:

1
2
3
4
5
6
7
8
9
10
11
// 初始化方法,描述参数会在测试报告中包含
- (instancetype)initWithDescription:(NSString *)expectationDescription;
// 描述文案
@property (copy) NSString *expectationDescription;
// 设置是否行为反向
@property (getter=isInverted) BOOL inverted;
// 设置期望的完成次数
@property (nonatomic) NSUInteger expectedFulfillmentCount;
@property (nonatomic) BOOL assertForOverFulfill;
// 触发一次完成动作
- (void)fulfill;

七 关于单元测试的几点建议

我们先不涉及到UI方面的自动化测试,只针对逻辑代码的单元测试,下面这些建议可供参考:

1. 在编码时,要尽量按照MVVM的模式进行开发,相比MVC模式,MVVM的逻辑代码都封装在VM里面,更利于进行脱离UI的测试。可以设想,如果将逻辑方法都写在View或ViewController中,则执行测试用例时就不得不引入很多额外的页面UI组件。

2. 编写测试用例时,有3个核心要考虑的点,即输入,输出和结果判定。我们通过输入来设置测试用例的初始状态,通过对输出的结果判定来决定测试用例是否通过。

3. 在开发中,编写的函数要尽量符合下面的特性:功能单一,有输入有输出。

4. 某些场景下,函数的功能是对输入的参数进行修改,而并没有返回值,则这种场景编写测试用例时,要判断的是执行函数操作后的原始变量是否符合预期。例如:

功能函数:

1
2
3
4
5
- (void)removeAllObj:(NSMutableArray *)array {
if ([array isKindOfClass:NSMutableArray.class]) {
[array removeAllObjects];
}
}

测试用例:

1
2
3
4
5
- (void)testRemoveObj {
NSMutableArray *array = [NSMutableArray arrayWithObjects:@"1", @"2", nil];
[self.viewModel removeAllObj:array];
XCTAssertEqual(array.count, 0);
}

5. 某些场景下,功能函数可能没有参数也没有返回值,其作用只是执行一段逻辑操作,例如存储文件,修改文件等。这时我们可以修改下功能函数,在函数内返回操作成功或失败的结果,测试用例使用此结果来作为是否通过的标准。

八 XCTest框架中的UI测试

相比逻辑功能测试,UI测试通常会麻烦一些。XCTest框架中也集成了UI测试相关的接口。通常在编写测试用例时,我们会将功能测试和UI测试分开编写。创建UI Test Bundle如下:

生成的模板代码中会自带启动性能测试用例,如下:

1
2
3
4
5
6
7
8
- (void)testLaunchPerformance {
if (@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *)) {
// This measures how long it takes to launch your application.
[self measureWithMetrics:@[[[XCTApplicationLaunchMetric alloc] init]] block:^{
[[[XCUIApplication alloc] init] launch];
}];
}
}

其中,XCTApplicationLaunchMetric对象配置为冷启动指标,其会计算从App启动到首帧渲染完成的时间。XCUIApplication用来实例化一个App应用实例,调用launch方法进行启动。默认其会启动当前应用,也可以通过设置bundleId来让其启动其他App进行测试,例如:

1
2
3
4
5
6
7
8
9
- (void)testLaunchPerformance {
if (@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *)) {
// This measures how long it takes to launch your application.
[self measureWithMetrics:@[[[XCTApplicationLaunchMetric alloc] init]] block:^{
XCUIApplication *application = [[XCUIApplication alloc] initWithBundleIdentifier:@"huishao.UnitTestDemo"];
[application launch];
}];
}
}

在UI测试中,我们通常会关注下面几项:

1. 检查页面某些元素是否存在

2.通过代码操作某些元素的交互

3.检查交互后的结果

因此,在UI测试中,如何查询到页面的元素是最重要的,这些工作由XCUIElementQuery类来完成,这个类相关的用法非常繁杂,例如我们要查找页面中标题为btn的按钮并进行点击操作,可以这么做:

1
2
3
4
5
6
7
- (void)testExample {
// UI tests must launch the application that they test.
XCUIApplication *app = [[XCUIApplication alloc] init];
[app launch];
XCUIElement *btn = app.staticTexts[@"btn"];
[btn tap];
}

如果页面查找不到此按钮,则此用例会执行失败。虽然页面元素的查找和定位非常繁琐,幸运的是Xcode提供了用户行为录制功能,我们可以将要测试的操作路径录制下来,点击Xcode的如下按钮即可:

录制完成后,此测试用例中会自动生成查找元素和操作的相关代码,之后执行此用例时将按照录制的步骤进行,如果页面元素没有按照预期出现,则用例会失败。

九 设备性能相关数据测试

前面我们有提到一个冷启动时间性能测试的配置项:XCTApplicationLaunchMetric。除此之外,XCTest框架中也默认提供了如CPU,内存等设备性能测试配置。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (void)testCPUPerformance {
if (@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *)) {
// This measures how long it takes to launch your application.
[self measureWithMetrics:@[[[XCTCPUMetric alloc] init]] block:^{
XCUIApplication *application = [[XCUIApplication alloc] initWithBundleIdentifier:@"huishao.UnitTestDemo"];
[application launch];
}];
}
}

- (void)testMemoryPerformance {
if (@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *)) {
// This measures how long it takes to launch your application.
[self measureWithMetrics:@[[[XCTMemoryMetric alloc] init]] block:^{
XCUIApplication *application = [[XCUIApplication alloc] initWithBundleIdentifier:@"huishao.UnitTestDemo"];
[application launch];
}];
}
}

测试执行完成后,报告中会有详细的性能数据,如下图:

专注技术,热爱生活,交流技术,也做朋友。

—— 珲少 QQ:316045346

同时,如果本篇文章让你觉得有用,欢迎分享给更多朋友,请标明出处。